JobSystemまなび


概要

学ぶ。

面倒臭い人は最後だけ見ると良い。



途中経過

こんな感じのコードにしてみる


using System;

using System.Collections;

using System.Collections.Generic;

using Unity.Collections;

using UnityEngine;


public class JS : MonoBehaviour

{

    [SerializeField] Transform[] targets;

    float[] velocity;


    // Start is called before the first frame update

    void Start()

    {

        velocity = new float[targets.Length];

    }


    // Update is called once per frame

    void Update()

    {


        var commands = new NativeArray<RaycastCommand>(targets.Length, Allocator.TempJob);

        var results = new NativeArray<RaycastHit>(targets.Length, Allocator.TempJob);


        for (var i = 0; i < targets.Length; i++)

        {

            var targetPosition = targets[i].position;

            var direction = Vector3.down;


            // 指定位置から下方向にレイキャストを行うコマンドをまとめる

            // RaycastCommand型は、結果にReycastHit型を返してくる。

            var command = new RaycastCommand(targetPosition, direction);

            commands[i] = command;

        }


        /*

            コマンドを実行、結果はresultsに入る。


            RaycastCommandで入力したコマンドは、RaycastHitを返してくる。

            (これはraycastCommand型のScheduleBatch関数がそういう実装になってる)

         */

        // 完了まで待つ(ここでデッドロックしないのはなぜなんだろう、継続になってる? それともそういうのを加味しないでいいくらい高速?)

        // -> 待ってる。十分に高速なら問題ないというロジック。

        // 最後の引数はjobの最大分散数。

        var commandJobHandle = RaycastCommand.ScheduleBatch(commands, results, 10);


        // jobHandleの終了を待つ

        commandJobHandle.Complete();


        // 破棄

        commands.Dispose();


        // 加速度をセット、速度がマイナス(下に移動)、かつレイキャストの距離が一定以下だったらぶつかったとみなす。

        for (var i = 0; i < targets.Length; i++)

        {

            if (velocity[i] > 0 && results[i].distance < 0.5f)

            {

                velocity[i] = -2;

            }


            velocity[i] += 0.098f;

        }

        results.Dispose();


        // 加速度分移動

        for (var i = 0; i < targets.Length; i++)

        {

            targets[i].localPosition += Vector3.down * velocity[i];

        }

    }

}

前提として、targets配列にはエディタ上でcubeオブジェクトをセットしておく。

あと、ヒット対象となる地面(Terrainとか)を用意しとく。


結果

いい感じにMainと他3スレッド(マシンが4コアなので合計4)で分散できた。

これワーカー数はコア数と一致するんだろうか、、


スクリーンショット 2019-01-05 22.33.15.png


アップで見るとこんな感じで、

・どれか一つのスレッドがResultJobを実行

・メインスレッドのWaitForJobGroupIDがそれを受ける

みたいな感じに見える。

スクリーンショット 2019-01-05 22.40.06.png


ここまでで、Raycastを打つ部分はJob化できてる。で、次に

・ヒットチェックして加速度を 制御する部分

・移動させる部分

をJob化してみる。


この時点で100fps前後。



ヒットチェック部分のJob化


グローバルに定義してあるvelocityはNativeArray<float>にしておく。

このあとJobからアクセスする要素を生成する時に代入するため。

    NativeArray<float> velocity;

んで、このインスタンスはOnEnableで生成、OnDisableで削除するようにする。


    void OnEnable()

    {

        velocity = new NativeArray<float>(targets.Length, Allocator.Persistent);

        for (int i = 0; i < targets.Length; i++)

        {

            velocity[i] = 1;

        }

    }


    void OnDisable()

    {

        velocity.Dispose();

    }



そんでまずHitCheckJobを、IJobParallelFor structを拡張する形で定義。

    struct HitCheckJob : IJobParallelFor

    {

        [ReadOnly] public NativeArray<RaycastHit> hits;

        public NativeArray<float> velocities;



        public void Execute(int i)

        {

            // 加速度をセット、速度がマイナス(下に移動)、かつレイキャストの距離が一定以下だったらぶつかったとみなす。

            if (velocities[i] > 0 && hits[i].distance < 0.5f)

            {

                // ヒットしたので加速度を-2にセットして浮かび上がらせる。

                velocities[i] = -2;

            }


            velocities[i] += 0.098f;

        }

    }


Jobを定義する際にパラメータに要素をセットすることで、後ほど実行 = Schedule関数を呼ぶ時に第一引数にセットした数だけExecuteが回ってくれる。


hitsなどの要素のセットについては、このジョブのインスタンスを生成する時にhits = みたいな形にして渡す。

hitsは参照オンリーなので、ReadOnlyをつけることができる。(velocityはR/W両方あるのでなんもできん)


次に、レイキャストコマンドを実行する部分の次に、HitCheckJobを生成する部分を追加する。

    void Update()

    {


        var commands = new NativeArray<RaycastCommand>(targets.Length, Allocator.TempJob);

        var results = new NativeArray<RaycastHit>(targets.Length, Allocator.TempJob);


        for (var i = 0; i < targets.Length; i++)

        {

            var targetPosition = targets[i].position;

            var direction = Vector3.down;


            // 指定位置から下方向にレイキャストを行うコマンドをまとめる

            // RaycastCommand型は、結果にReycastHit型を返してくる。

            var command = new RaycastCommand(targetPosition, direction);

            commands[i] = command;

        }


        // ジョブの初期化をする

        var hitCheckJob = new HitCheckJob()

        {

            hits = results,

            velocities = velocity

        };


ここまでで、次の状態のhitCheckJobのインスタンスが手に入る。

・このあとRaycastの結果 = RaycastHitのNativeArrayを返してくるresultsの参照を、hitsに代入

・速度が入るvelocityのNativeArrayの参照を、Job内部で値を入れるvelocitiesに代入


そんでjobを実行。

        var commandJobHandle = RaycastCommand.ScheduleBatch(commands, results, 10);

        var hitcheckHandle = hitCheckJob.Schedule(targets.Length, 10, commandJobHandle);

        hitcheckHandle.Complete();


先ほどまでRaycastCommandの結果をそのままCompleteしていたのを、

新たにhitcheckHandleインスタンスをhitCheckJob.Scheduleから生成し、そのjobの完了を待つように変更する。


この際、まずレイキャストを打ってからヒットチェックがしたいため、hitcheckJobのSchedule関数にcommandJobHandleのインスタンスをセット、

必ず

commandJobHandle

hitcheckHandle

という順番で処理が実行されるようにする。


ここまでで、ヒットチェックがjob化できた。



移動もJob化する

あとは応用で、

・加速度分移動する

という処理をJob化する。


まず位置(transform)をいじるので、transformに対してJob内から干渉できるJobを作成する。

    struct ApplyPositionJob : IJobParallelForTransform

    {

        [ReadOnly] public NativeArray<float> velocities;


        public void Execute(int i, TransformAccess transform)

        {

            transform.localPosition += Vector3.down * velocities[i];

        }

    }


こんな感じで、IJobParallelForTransform structを拡張したジョブを定義する。

Executeの内部処理についてはちょっと特殊で、


・IJobOarallelForTransform関数のSchedule関数はTransformAccessArray(Transform[] transforms)という型を引数にとり

・Execute実行時に transforms へのアクセスが可能なように引数に入っている

という感じ。


Transformそのままだと参照型になってしまっていて値型ではないのでJobからアクセスできない、というのがあるらしい。

なので、

        var transformAccess = new TransformAccessArray(targets);


とか定義して、Schedule関数に対して渡す。

さらにScheduleの第二引数としてヒットチェックのjobHandleを渡し、順番を制御する。

        var applyPosHandle = applyPositionJob.Schedule(transformAccess, hitcheckHandle);


これで、

・レイキャスト

・ヒットチェック

・移動


の順番で処理が行われるようになっている。

処理が終わったら

        transformAccess.Dispose();


も忘れないようにする。ない場合エラー出してくれるんで便利。


ここまででヒットチェックと移動のJob化ができた。やったぜ。

この時点で100fps前後。まあ変わらん。


Raycast、Hitcheck、ApplyPosがJob化できた図。

スクリーンショット 2019-01-06 0.10.59.png


ここからさらに高速化するには、「Completeをやめてメインスレッドのロックを外す」ということをする感じになる。



Completeを次のフレームで実行する(WaitForJobGroupにかかる時間 = メインスレッドでの待ちを減らす)

Updateで行うコードを次のような感じにする。


    void Update()

    {


        // applyPosHandle の終了を待つ

        applyPosHandle.Complete();


        for (var i = 0; i < targets.Length; i++)

        {

            var targetPosition = targets[i].position;

            var direction = Vector3.down;


            // 指定位置から下方向にレイキャストを行うコマンドをまとめる

            // RaycastCommand型は、結果にReycastHit型を返してくる。

            var command = new RaycastCommand(targetPosition, direction);

            commands[i] = command;

        }


        // ジョブの初期化をする

        var hitCheckJob = new HitCheckJob()

        {

            hits = results,

            velocities = velocity

        };


        var applyPositionJob = new ApplyPositionJob()

        {

            velocities = velocity

        };


        // レイキャスト、ヒットチェックまでは変わらず、applyPosのハンドルをグローバル変数に入れる。

        var commandJobHandle = RaycastCommand.ScheduleBatch(commands, results, 10);

        var hitcheckHandle = hitCheckJob.Schedule(targets.Length, 10, commandJobHandle);

        applyPosHandle = applyPositionJob.Schedule(transformAccess, hitcheckHandle);

    }


applyPosHandleをグローバルに定義して、その終了待ち=CompleteをUpdateの先頭で行う。

グローバル化に祭して、いままでUpdateの最後でDisposeしていたテンポラリなNativeArrayもすべてグローバルな定義に変わる必要がある。

Jobのインスタンス生成(参照渡し)と、レイキャストコマンドの生成/セットは変わらず。


この時点で103fpsくらいになった。ちょっと高速化。

プロファイラを見てみると、Update関数が実行されてからJobが実行されている。やったぜ。


スクリーンショット 2019-01-06 0.35.45.png


あと、WaitForJobIDが消えた。(乗らないくらい短いか、見落としてるか。)



ちょっとBurst化してみる

JobをBurstCompiler任せにしてみる。

Job定義のstructに BurstCompile のアノテーションをつけるだけ。


-> 爆速になった。

110fpsくらい出てる。



値を得られるようにする

IJobを拡張したstructを用意する。

    struct IsHit : IJob

    {

        [ReadOnly] public NativeArray<RaycastHit> hits;

        [WriteOnly] public NativeArray<int> isHit;


        public void Execute()

        {

            for (var i = 0; i < hits.Length; i++)

            {

                // 距離が1fより小さかったら、ヒットしている

                if (hits[i].distance < 1f)

                {

                    isHit[0] = 0;

                    return;

                }

            }


            // それ以外であればすべてのインスタンスがヒットしてない

            isHit[0] = 1;

        }

    }


Update内でヒットのハンドルを生成、ここでサイズ1のNativeArray<int>を入れる。

        var hitHandle = new IsHit()

        {

            hits = results,

            isHit = new NativeArray<int>(1, Allocator.TempJob)

        };


        // レイキャスト、ヒットチェックまでは変わらず、applyPosのハンドルをグローバル変数に入れる。

        var commandJobHandle = RaycastCommand.ScheduleBatch(commands, results, 10);


        var isHitHandle = hitHandle.Schedule(commandJobHandle);

        var hitcheckHandle = hitCheckJob.Schedule(targets.Length, 10, commandJobHandle);


        applyPosHandle = applyPositionJob.Schedule(transformAccess, hitcheckHandle);


        // ヒット判定だけは即座に終了させる

        isHitHandle.Complete();


        var isHit = hitHandle.isHit[0];

        if (isHit != 1)

        {

            Debug.Log("ヒットした");

        }


        // hitHandleisHitはこのブロック内で生成しているパラメータなので、ここで消費する。

        hitHandle.isHit.Dispose();


isHitHandleを生成、レイキャストのあとに実行する。

で、ヒット判定だけをCompleteにして値を受け取李判断に使う。便利。



コルーチン化する

ちょっとは書きやすくなるか?

    IEnumerator Start()

    {

        var applyPosHandle = default(JobHandle);

        var commands = new NativeArray<RaycastCommand>(targets.Length, Allocator.Persistent);

        var results = new NativeArray<RaycastHit>(targets.Length, Allocator.Persistent);


        var transformAccess = new TransformAccessArray(targets);

        var velocity = new NativeArray<float>(targets.Length, Allocator.Persistent);


        for (int i = 0; i < targets.Length; i++)

        {

            velocity[i] = 1;

        }


        disposeAct = () =>

        {

            try

            {

                applyPosHandle.Complete();


                velocity.Dispose();

                commands.Dispose();

                results.Dispose();

                transformAccess.Dispose();

            }

            catch { }

        };


        // ジョブの初期化をする

        var hitCheckJob = new HitCheckJob()

        {

            hits = results,

            velocities = velocity

        };


        var applyPositionJob = new ApplyPositionJob()

        {

            velocities = velocity

        };


        while (true)

        {


            for (var i = 0; i < targets.Length; i++)

            {

                var targetPosition = targets[i].position;

                var direction = Vector3.down;


                // 指定位置から下方向にレイキャストを行うコマンドをまとめる

                // RaycastCommand型は、結果にReycastHit型を返してくる。

                var command = new RaycastCommand(targetPosition, direction);

                commands[i] = command;

            }


            var hitHandle = new IsHit()

            {

                hits = results,

                isHit = new NativeArray<int>(1, Allocator.TempJob)

            };



            // レイキャスト、ヒットチェックまでは変わらず、applyPosのハンドルをグローバル変数に入れる。

            var commandJobHandle = RaycastCommand.ScheduleBatch(commands, results, 10);


            var isHitHandle = hitHandle.Schedule(commandJobHandle);

            var hitcheckHandle = hitCheckJob.Schedule(targets.Length, 10, commandJobHandle);


            applyPosHandle = applyPositionJob.Schedule(transformAccess, hitcheckHandle);


            // ヒット判定だけは即座に終了させる

            isHitHandle.Complete();


            var isHit = hitHandle.isHit[0];

            if (isHit != 1)

            {

                Debug.Log("ヒットした");

            }


            // hitHandleisHitはこのブロック内で生成しているパラメータなので、ここで消費する。

            hitHandle.isHit.Dispose();


            yield return null;


            // applyPosHandle の終了を待つ

            applyPosHandle.Complete();

        }

    }


NativeArrayとか、jobHandleの生成を一度きりにできた。

参照だけをなんとかできるのが良い。



まとめ


コード全体を書いとく。

using System;

using System.Collections;

using System.Collections.Generic;

using Unity.Burst;

using Unity.Collections;

using Unity.Jobs;

using UnityEngine;

using UnityEngine.Jobs;


public class JS : MonoBehaviour

{

    [SerializeField] Transform[] targets;


    [BurstCompile]

    struct HitCheckJob : IJobParallelFor

    {

        [ReadOnly] public NativeArray<RaycastHit> hits;

        public NativeArray<float> velocities;



        public void Execute(int i)

        {

            // 加速度をセット、速度がマイナス(下に移動)、かつレイキャストの距離が一定以下だったらぶつかったとみなす。

            if (velocities[i] > 0 && hits[i].distance < 0.5f)

            {

                // ヒットしたので加速度を-2にセットして浮かび上がらせる。

                velocities[i] = -2;

            }


            velocities[i] += 0.098f;

        }

    }


    [BurstCompile]

    struct ApplyPositionJob : IJobParallelForTransform

    {

        [ReadOnly] public NativeArray<float> velocities;


        public void Execute(int i, TransformAccess transform)

        {

            transform.localPosition += Vector3.down * velocities[i];

        }

    }


    [BurstCompile]

    struct IsHit : IJob

    {

        [ReadOnly] public NativeArray<RaycastHit> hits;

        [WriteOnly] public NativeArray<int> isHit;


        public void Execute()

        {

            for (var i = 0; i < hits.Length; i++)

            {

                // 距離が1fより小さかったら、ヒットしている

                if (hits[i].distance < 1f)

                {

                    isHit[0] = 0;

                    return;

                }

            }


            // それ以外であればすべてのインスタンスがヒットしてない

            isHit[0] = 1;

        }

    }


    private Action disposeAct = () => { };


    IEnumerator Start()

    {

        var applyPosHandle = default(JobHandle);

        var commands = new NativeArray<RaycastCommand>(targets.Length, Allocator.Persistent);

        var results = new NativeArray<RaycastHit>(targets.Length, Allocator.Persistent);


        var transformAccess = new TransformAccessArray(targets);

        var velocity = new NativeArray<float>(targets.Length, Allocator.Persistent);


        for (int i = 0; i < targets.Length; i++)

        {

            velocity[i] = 1;

        }


        disposeAct = () =>

        {

            try

            {

                applyPosHandle.Complete();


                velocity.Dispose();

                commands.Dispose();

                results.Dispose();

                transformAccess.Dispose();

            }

            catch { }

        };


        // ジョブの初期化をする

        var hitCheckJob = new HitCheckJob()

        {

            hits = results,

            velocities = velocity

        };


        var applyPositionJob = new ApplyPositionJob()

        {

            velocities = velocity

        };


        while (true)

        {


            for (var i = 0; i < targets.Length; i++)

            {

                var targetPosition = targets[i].position;

                var direction = Vector3.down;


                // 指定位置から下方向にレイキャストを行うコマンドをまとめる

                // RaycastCommand型は、結果にReycastHit型を返してくる。

                var command = new RaycastCommand(targetPosition, direction);

                commands[i] = command;

            }


            var hitHandle = new IsHit()

            {

                hits = results,

                isHit = new NativeArray<int>(1, Allocator.TempJob)

            };



            // レイキャスト、ヒットチェックまでは変わらず、applyPosのハンドルをグローバル変数に入れる。

            var commandJobHandle = RaycastCommand.ScheduleBatch(commands, results, 10);


            var isHitHandle = hitHandle.Schedule(commandJobHandle);

            var hitcheckHandle = hitCheckJob.Schedule(targets.Length, 10, commandJobHandle);


            applyPosHandle = applyPositionJob.Schedule(transformAccess, hitcheckHandle);


            // ヒット判定だけは即座に終了させる

            isHitHandle.Complete();


            var isHit = hitHandle.isHit[0];

            if (isHit != 1)

            {

                Debug.Log("ヒットした");

            }


            // hitHandleisHitはこのブロック内で生成しているパラメータなので、ここで消費する。

            hitHandle.isHit.Dispose();


            yield return null;


            // applyPosHandle の終了を待つ

            applyPosHandle.Complete();

        }

    }


    void OnDisable()

    {

        disposeAct();

    }

}




JobSystemは、次のようなもの

・IJob系を継承したstructを定義すると、Executeメソッド内がworkerスレッド内で実行される。

IJob Execute()

IJobParallelFor Execute(index)

IJobParallelForTransform Execute(index, transformAccess)


など、namespace Unity.Jobs 以下のもの。

これにより、Execute内に書いた処理がworkerスレッドで分散して高速に実行される。


Executeメソッド内では、structに定義したパラメータに触れるほか、indexを使ってパラメータの特定のindexに対して変更をかけたりができる。


イメージ的には、Executeメソッドは一つのworkerスレッドからアクセスされるものではなく、

複数のworkerスレッドから実行されえる。そのため、indexパラメータが渡ってくるがその実行元がどのworkerかはわからない。



・IJob系のstructをインスタンス化し、Schedule関数を実行すると、Jobがスケジューリングされてworkerスレッドで実行される。

インスタンス化時に、各ジョブを跨いで編集する対象をセットする。


JobAインスタンス{ param1 = shared1 }

JobBインスタンス{ param2 = shared1 }

のように、shared1というパラメータを各ジョブで共有させることで、一つのパラメータを複数のジョブでガチャガチャ編集することができる。



・Jobのスケジューリング時に、引数として

IJob Schedule(完了を待つほかのJob)

IJobParallelFor Scuedule(扱う対象の数, 分散するworker数みたいな数値, 完了を待つほかのJob)

IJobParallelForTransform Scuedule(TransformAccessArray, 完了を待つほかのJob)

などを渡せる。



・Jobの実行待ちは、Complete関数で行うことができる。



・NativeArray<T>を使って、Job(Workerスレッド)からアクセスする要素を定義する。



・NativeArray<T>はかならずDisposeする必要がある。